Skip to content

fix(core): harden scan durability and idempotency (#303) - #325

Open
SHAURYAKSHARMA24 wants to merge 9 commits into
openshield-org:devfrom
SHAURYAKSHARMA24:303-scan-leases-fencing
Open

fix(core): harden scan durability and idempotency (#303)#325
SHAURYAKSHARMA24 wants to merge 9 commits into
openshield-org:devfrom
SHAURYAKSHARMA24:303-scan-leases-fencing

Conversation

@SHAURYAKSHARMA24

@SHAURYAKSHARMA24 SHAURYAKSHARMA24 commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

This draft PR implements the complete #303 hardening contract: transaction recovery, fenced scan leases, idempotent result persistence, durable scan admission, durable CVE enrichment, and bounded operational signals.

Problems fixed

  • Aborted PostgreSQL transactions no longer poison worker progress; broken connections are discarded and reacquired.
  • Expired or stale scan workers cannot write authoritative completion, failure, evaluations, or findings.
  • Repeated result delivery no longer creates duplicate evaluations or findings.
  • Concurrent/replayed API triggers cannot create uncontrolled duplicate active scans.
  • CVE enrichment is no longer owned by a Gunicorn daemon thread and no longer stops after a single NVD page.
  • Operators now have worker, queue, lease, retry, and last-success visibility.

Architecture

  • Scan leases and fencing: PostgreSQL claims have owner, expiry, monotonic fencing token, renewal, stale recovery, and fenced final writes.
  • Idempotent persistence: findings are uniquely identified by scan, rule, canonical resource scope, and an optional rule-specific discriminator. Mutable fields are updated with ON CONFLICT; rule evaluations are uniquely keyed by scan, rule, and resource and are upserted.
  • Admission: a transaction-scoped PostgreSQL advisory lock plus partial unique indexes enforce one pending/running scan per subscription and a unique subscription/idempotency-key pair. Same semantics replay the logical scan; changed semantics conflict. OPENSHIELD_MAX_SCANS_PER_SUBSCRIPTION_PER_HOUR provides an explicit optional time-window policy; one active scan remains the enforced concurrency quota.
  • Durable enrichment: a completed fenced scan atomically creates one PostgreSQL enrichment job. The existing durable worker claims, renews, checkpoints, retries with bounded exponential backoff, recovers expired leases, and fences stale writers. NVD retrieval follows totalResults through every page.
  • Metrics: /metrics reads bounded PostgreSQL aggregates; labels are only queue (scan/enrichment) and worker_type.

Database migrations

  1. e4f7a9b2c6d8 — renewable scan leases and fencing tokens.
  2. f2b6d8e1a4c9 — finding identities and rule evaluations. Existing findings receive distinct legacy:<id> keys; no legacy rows are silently collapsed.
  3. a7c5e9d2f1b4 — durable scan admission/idempotency indexes.
  4. c9e1a5b7d3f2 — durable fenced enrichment jobs.
  5. d4a8c1e6b2f9 — worker heartbeat storage for operational metrics.

There is one Alembic head. Clean base-to-head, #325's original e4f7a9b2c6d8-to-head, and downgrade/upgrade paths were validated on PostgreSQL.

Concurrency guarantees

All authoritative scan-result writes re-check lease owner, fencing token, running state, and unexpired lease under FOR UPDATE in the same transaction as persistence. Once worker A loses its lease and worker B reclaims with a newer token, A cannot update scan state, findings, evaluations, or enrichment progress. PostgreSQL unique constraints and upserts make duplicate API/result/job delivery converge on one logical record.

Deployment

  1. Stop or drain old scan workers; mixed old/new workers are unsafe because old workers cannot satisfy the fencing contract.
  2. Apply Alembic migrations through d4a8c1e6b2f9.
  3. Deploy this API and the existing scanner/worker.py process. The worker now processes both scan and enrichment jobs.
  4. Monitor /metrics for worker liveness, queue age, lease age, retries, and last successful scan.

Tests

Acceptance criteria

  • All transactions rollback on failure and discard/reacquire broken connections.
  • Claims use renewable leases with owner, expiry and fencing token.
  • Heartbeat and completion updates require the current fencing token.
  • Evaluation/finding persistence is idempotent using stable unique keys/upserts.
  • Scan admission has per-subscription quotas, one-active-scan deduplication and idempotency keys.
  • Enrichment is a durable claimed job with retries, stale recovery and complete pagination.
  • PostgreSQL-backed fault-injection tests cover abort, restart, duplicate delivery, lease expiry and two-worker races.
  • Metrics include worker heartbeat, oldest queue age, lease age, retry count and last successful complete scan.

Related

Closes #303

Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
@SHAURYAKSHARMA24 SHAURYAKSHARMA24 added bug Something isn't working core Core team ownership not for students priority: high Important, should be fixed in the current sprint labels Aug 29, 2026
@SHAURYAKSHARMA24 SHAURYAKSHARMA24 self-assigned this Aug 29, 2026
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
@SHAURYAKSHARMA24 SHAURYAKSHARMA24 changed the title fix(core): fence scan worker leases and persistence (#303) fix(core): harden scan durability and idempotency (#303) Aug 29, 2026
Comment thread api/routes/scans.py Fixed
Comment thread api/routes/scans.py Fixed
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
@SHAURYAKSHARMA24
SHAURYAKSHARMA24 marked this pull request as ready for review August 29, 2026 19:37
@m-khan-97

Copy link
Copy Markdown
Collaborator

@SHAURYAKSHARMA24, this is the canonical track for #303’s durability layer: transaction recovery, leases/fencing, idempotent admission and writes, durable enrichment, and worker telemetry. One integration boundary must be resolved before lead review: migration f2b6d8e1a4c9 creates rule evaluations and associated persistence semantics that overlap #321, which is the already-agreed #263 evaluation-contract implementation. Please coordinate with Dipesh and either stack/rebase #325 on the accepted #321 contract or remove the duplicate evaluation-schema ownership from this PR. We must not merge two competing rule_evaluations definitions or aggregation contracts. Keep the fencing/idempotency guarantees around whichever canonical evaluation model is selected.

@ritiksah141 ritiksah141 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed all 28 files end to end: the five migrations, the lease and fencing layer in api/models/finding.py, the worker loop, the new enrichment worker, the scan routes, observability, and the NVD client. Also ran the suite locally against a scratch Postgres with the migrations applied from base to head.

The lease and fencing design is correct and applied consistently. The fault-injection tests are thorough and map to every acceptance criterion in #303. Two functional gaps should be resolved before merge, plus a few smaller items.

Must fix

  1. Terminally failed enrichment jobs are unrecoverable. After 3 attempts fail_enrichment_job marks the job failed. From there POST /api/scans//enrich returns the dead job via ON CONFLICT DO NOTHING, claim_next_enrichment_job only picks pending, and recover_stale_enrichment_jobs only handles expired running leases. So a scan whose enrichment exhausts its retries is stuck unless someone edits the DB by hand. This is a regression from the previous thread-based path, where a re-POST simply worked. Please reset a terminal failed job back to pending on enqueue, or return an explicit 409 telling the operator.

  2. rule_evaluations is dead in production. The migration, unique constraint, the save_scan upsert, and the tests all exist, but nothing populates it: scanner/engine.py run_scan returns no evaluations key and no production code emits one. Either wire the engine to emit evaluations, or scope this explicitly as storage-only for now. As written, the #303 evaluations criterion looks met but is not observable in production.

Should fix

  1. uq_scans_one_active_per_subscription can leave an INVALID index. If a deployment already has two or more active (pending/running) scans for one subscription, CREATE UNIQUE INDEX CONCURRENTLY fails and leaves an invalid index behind silently. Add a dedupe/cleanup note to the deployment-order doc, or a cleanup step in the migration before the index is created.

  2. The enrichment fixture assumes the pending queue is empty. This is a general test-isolation issue, not an environment quirk. claim_next_pending_scan claims the oldest pending scan, but the fixture assumes it claims the scan it just created. Any developer who sets both DATABASE_URL and AZURE_SUBSCRIPTION_ID (common when developing against a real local Postgres plus Azure) and runs the full suite will hit this: the pre-existing role tests in test_auth.py admit a real pending scan, and the enrichment fixture then claims that older row instead of its own scan, so save_scan correctly raises LostLease. Make the fixture robust by truncating scans/enrichment_jobs in setup, or by asserting on the claimed scan_id.

Nits

  1. docs/api-reference.md is not updated for Idempotency-Key, the 409/429 responses, the 200 replay response, and the enrich job_id response.
  2. The new env vars OPENSHIELD_MAX_SCANS_PER_SUBSCRIPTION_PER_HOUR, SCAN_LEASE_SECONDS, and SCAN_HEARTBEAT_SECONDS are missing from .env.example.
  3. worker_heartbeats grows unbounded: one row per worker restart, never cleaned.
  4. /metrics now runs full DB aggregates on every scrape with no caching. Fine at current scale, worth a note.
  5. Enrichment jobs run serially ahead of scans in the same worker loop. Worth documenting as a throughput characteristic.

What looks good

Fencing is correct and applied uniformly on every authoritative write. The fault-injection suite is the most thorough in the repo and covers every #303 acceptance criterion. The deployment-order doc is honest about the migrate-then-run-workers constraint. Error sanitization is preserved. NVD pagination (resultsPerPage=2000, following totalResults, bounded retries with 429 backoff) is done properly. Metrics are bounded-cardinality.

@ritiksah141

ritiksah141 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Following up on @m-khan-97's integration note, here is the concrete side-by-side between this PR's rule_evaluations handling and the one in #321, so we converge on a single canonical model. Short version: this PR should drop the evaluation schema and keep the fencing, and the two save_scan implementations need to be reconciled, not just the table.

Schema: rule_evaluations

Aspect #321 (3f59f83a5253) #325 (f2b6d8e1a4c9)
Core columns (id, scan_id, rule_id, resource_id, resource_type, status, reason_code, reason, evidence, finding_id, evaluated_at) yes identical
PK, FK scan_id, FK finding_id ON DELETE SET NULL yes identical
Unique (scan_id, rule_id, resource_id), same constraint name yes same name
Status CHECK (5 values), same constraint name yes same name
resource_id <> '' CHECK yes yes
Index on scan_id yes yes
Index on rule_id yes no
Index on status yes no
CHECK: reason_code required for UNKNOWN/ERROR/NOT_APPLICABLE yes no

The core columns, keys, and constraints overlap and share names, so whichever migration runs second fails with "relation already exists." #321 is a strict superset: it adds the rule_id and status indexes and the reason_code-required CHECK.

Producer (who emits evaluations)

#321 #325
scanner/evaluation.py (EvaluationStatus, RuleEvaluation, subscription_scope_id, aggregate_status) added none
engine.run_scan calls evaluate() per rule and collects evaluations added none, engine untouched
FAIL evaluation contributes a finding (deduped vs scan() by rule_id+resource_id) added none
Returns evaluations in the scan result added reads it but never populated

Decisive difference: this PR has no producer, so its evaluations are always empty in production. Only #321 makes evaluations observable.

save_scan persistence semantics

Both PRs are full rewrites of the same method with incompatible idempotency models, so the conflict is larger than the table.

#321 #325
Signature save_scan(scan_result) save_scan(scan_result, lease_owner, fencing_token)
Fencing / lease check none SELECT FOR UPDATE owner+token+unexpired, else LostLease
Findings idempotency DELETE all, re-insert (full replace) UPSERT ON CONFLICT (scan_id, finding_key) + delete-absent
Evaluations idempotency DELETE all, plain re-insert UPSERT ON CONFLICT (scan_id, rule_id, resource_id) + delete-absent
FAIL eval linked to finding via (rule_id, resource_id) in same txn yes yes
Requires rule_id + resource_id on every evaluation no yes, raises ValueError

Compliance score (the actual #263 bug)

#321 #325
Rewrites get_compliance_score to read statuses from rule_evaluations yes no
aggregate_status FAIL > ERROR > UNKNOWN > PASS > NOT_APPLICABLE yes no
No evaluation row reports UNKNOWN instead of PASS yes no
Score excludes NOT_APPLICABLE from denominator, never counts UNKNOWN/ERROR as pass yes no

Only #321 fixes the score-inflation bug.

Recommended resolution

  1. feat(engine): add rule evaluation coverage contract (#263) #321 owns the evaluation contract: schema, scanner/evaluation.py, engine producer, aggregation, and the compliance-score fix. That is the agreed feat: persist PASS/FAIL/ERROR/NOT_APPLICABLE per rule per resource, fix compliance score #263 scope and the only version that is observable.
  2. This PR drops its rule_evaluations table creation and its evaluation upsert, and keeps everything else: leases, fencing, admission, enrichment, metrics.
  3. The real merge point is save_scan, not just the table. The final save_scan should keep this PR's fenced, upsert skeleton (ownership check, lease clear, findings upsert by finding_key) and fold feat(engine): add rule evaluation coverage contract (#263) #321's evaluation field set and FAIL-to-finding_id linkage into that same fenced transaction. Given the replay-safety goal of core: harden scan transactions, leases, idempotency, and durable background work #303, evaluations should use the upsert-plus-delete-absent model.
  4. Ordering: this only composes cleanly if feat(engine): add rule evaluation coverage contract (#263) #321 merges first (or both merge as a deliberate pair), then this PR rebases its remaining migrations on top of 3f59f83a5253. Both are currently OPEN and both branch off d8e4f6a1b2c3, so merging in the wrong order produces two Alembic heads and breaks the single-head CI gate.

One integration detail for whoever reconciles: #321's engine adds evaluate()-derived FAIL findings to the findings list, and this PR derives finding_key from rule_id plus resource scope plus discriminator. Those compose, but make sure evaluate()-derived findings get stable finding_keys so the upsert stays idempotent.

@parthrohit22 parthrohit22 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is careful work — the connection-lifecycle rework (discard-and-reacquire on an aborted/unknown-status transaction instead of trusting a poisoned connection), the stable_finding_key() identity hash excluding presentation fields so retries update rather than duplicate, and the legacy:<id> backfill for existing rows before adding the unique index (with CREATE INDEX CONCURRENTLY in an autocommit block, so it doesn't lock writes) are all the right calls. CI is green.

One real blocker before this can merge, not about the code itself: this PR's first migration (e4f7a9b2c6d8) forks off d8e4f6a1b2c3, same as #310's 3a76ff935bf6 — both currently share that parent, so if both land as-is alembic heads ends up with two heads. Whichever of #310/#325 merges second needs to rebase and repoint its down_revision, same as the #308/#310 fork we resolved earlier. Given this PR also touches api/models/finding.py/scanner/worker.py/api/routes/scans.py — the same files #310 rewrites — that rebase is going to be a real one, not just a migration-pointer fix. Worth coordinating merge order with #310 explicitly before either goes in.

Requesting changes only for the migration fork — nothing else jumped out as wrong in what I read.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working core Core team ownership not for students priority: high Important, should be fixed in the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: harden scan transactions, leases, idempotency, and durable background work

5 participants